Skip to content

perf: build std string encoders and escapers in linear time - #699

Merged
kacy merged 1 commit into
mainfrom
perf/linear-string-builders
Aug 11, 2026
Merged

perf: build std string encoders and escapers in linear time#699
kacy merged 1 commit into
mainfrom
perf/linear-string-builders

Conversation

@kacy

@kacy kacy commented Aug 11, 2026

Copy link
Copy Markdown
Owner

A set of std functions built their output with s = s + piece inside a loop. Each append recopies the whole string accumulated so far, so the work grew quadratically with the input. This is the same shape #686 fixed in base64; the worst availability offenders (redis, http2, the io buffered readers) were fixed in #687/#689/#695, and this cleans up the char-by-char remainder. Each site now accumulates through a byte buffer or a list join and materializes the string once at the end.

The network-facing hot paths are the ones that matter most: url percent encode/decode (every query param, form body, and CSRF token), json string unescaping inside parse, grpc-message percent coding, and template <% for %> rendering. The rest are std primitives a lot of higher-level code sits on: hex, base32, and base58 encoding; the log, metrics, and html escapers; strings swap_case/reverse/repeat; text fold/sanitize/from_chars/from_code_points; regex replace_all; term strip; collections join_with; yaml quoted-scalar decoding; and config json string reading.

While surveying I found and fixed several sites beyond the ticket's list, all the same shape: grpc percent_encode/percent_decode, yaml decode_double_quoted/decode_single_quoted/repeat_newlines, strings swap_case/reverse/repeat_text, text from_chars/from_code_points/sanitize/fold, regex replace_all, term strip, collections join_with, metrics normalize_metric_name, and config config_json_read_string. No non-std code was touched.

Two decode paths (url decode, grpc percent_decode) accumulate through a List[String] join rather than a byte buffer on purpose: a decoded byte can be invalid UTF-8 (%ff), which a buffer round-trip through UTF-8 validation would reject, so the list preserves the existing behavior exactly.

what was tested

Output is byte-identical. For every fixed site I ran a checksum sweep at n = 0, 1, 2, 3, 17, 255, 1000, 4096, 65537 against the old and new std and diffed: identical at every size. The encoders and escapers were also anchored to external oracles, not self-round-tripping:

  • url percent encode/decode vs python urllib.parse — matched at every size
  • json string unescape (through parse, surrogate pairs included) vs python json.loads — matched
  • hex vs python binascii.hexlify over all 256 byte values — matched
  • base32 / base32hex vs the RFC 4648 vectors; base58 vs the bitcoin-alphabet vector — matched

A new golden test, tests/cases/test_string_builder_linearity.pith, locks the user-visible encoders (url, json, hex, base32, base58, html, template) to their oracle-verified output.

The curve flattens. Interleaved A/B wall-time (min of 3 trials) at 4x size steps, so the shape is what to read — quadratic should step ~16x, linear ~4x:

op old 8k -> 32k -> 128k new 8k -> 32k -> 128k
url encode 5 -> 169 -> 6976 ms (~34x, ~41x) 1 -> 5 -> 20 ms (~5x, ~4x)
url decode 1 -> 14 -> 192 ms (~14x) 1 -> 4 -> 17 ms (~4x)
json parse (escapes) 2 -> 11 -> 73 ms (~6x) 2 -> 9 -> 37 ms (~4x)
to_hex 2 -> 47 -> 832 ms (~20x) 0 -> 1 -> 2 ms
base32 2 -> 30 -> 499 ms (~16x) 0 -> 1 -> 6 ms
html escape 1 -> 12 -> 309 ms (~13x/~26x) 0 -> 1 -> 4 ms
swap_case 1 -> 12 -> 186 ms (~15x) 1 -> 3 -> 12 ms (~4x)
text fold 3 -> 18 -> 197 ms (~11x) 2 -> 8 -> 31 ms (~4x)
template for-loop 1 -> 7 -> 66 ms (~9x) 1 -> 6 -> 26 ms (~4x)
yaml quoted scalar 2 -> 10 -> 83 ms (~8x) 2 -> 8 -> 33 ms (~4x)
term strip 0 -> 3 -> 32 ms (~11x) 0 -> 1 -> 5 ms

A 200k-iteration leak smoke over the rewritten builders held steady at ~600 MB peak RSS on both old and new std (no per-call leak from the parts lists or buffers).

Suites, one at a time: run-regressions-only 342/0, check-invalid-only 44/0 (after rebuilding self-host/pith_main), run-examples-self-only 121/121. No doc or example describes these functions' internal accumulation strategy, so nothing there needed updating.

these functions accumulated their output with `s = s + piece` in a loop,
so each append recopied the whole string built so far and the work grew
quadratically with the input. rebuild each one through a byte buffer or a
list join and materialize the string once at the end, the same shape #686
gave base64.

the network-facing hot paths are url percent encode/decode (every query
param, form body, and csrf token), json string unescaping inside parse,
grpc-message percent coding, and template `<% for %>` rendering. the rest
are std primitives a lot of higher-level code sits on: hex, base32, and
base58 encoding; the log, metrics, and html escapers; strings swap_case,
reverse, and repeat; text fold, sanitize, from_chars, from_code_points;
regex replace_all; term strip; collections join_with; yaml quoted-scalar
decoding; and config json string reading.

output is byte-identical: the encoders and escapers are pinned against
python's urllib, json, and binascii and the rfc base32/base58 vectors at
sizes from 0 to 65537, and a new golden test locks the user-visible ones.
@kacy
kacy merged commit ce6ff49 into main Aug 11, 2026
2 checks passed
@kacy
kacy deleted the perf/linear-string-builders branch August 11, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant